Skip to content

fix(ui): pin lucide icon rasters globally to stop subpixel wobble - #897

Merged
matt2e merged 4 commits into
mainfrom
chat-ui-wobble
Jul 30, 2026
Merged

fix(ui): pin lucide icon rasters globally to stop subpixel wobble#897
matt2e merged 4 commits into
mainfrom
chat-ui-wobble

Conversation

@matt2e

@matt2e matt2e commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Summary

Chat/timeline icons wobbled by a subpixel on hover and repaint in WKWebView because tiny glyph rasters were re-snapped whenever their surroundings repainted. This branch fixes that at the source and removes the per-site workarounds it obsoletes.

  • Pin every lucide icon (svg.lucide) onto its own compositor layer via the existing transform: translateZ(0) rule in app.css, so repaints can't re-snap glyph rasters anywhere in the app.
  • Add stable-raster to the non-lucide brand marks in AgentIcon.svelte (goose PNG mask and the claude/codex/amp/copilot/cursor inline SVGs), which the global lucide pin doesn't reach.
  • Drop the now-redundant stable-raster-glyphs hints from badge.svelte, button.svelte, and TimelineRow.svelte; the class itself remains for the few non-lucide raster surfaces that still need it.

🤖 Generated with Claude Code

matt2e and others added 3 commits July 30, 2026 12:10
Hovering a collapsed tool call underlines its verb, repainting the row.
The status-icon SVG is centered in an 11px/1.5-line-height flex row, so it
sits at a fractional pixel offset; without its own compositor layer WebKit
re-rasterizes it during the hover repaint and it re-snaps between pixels,
producing a visible wobble.

Pin the icon with the shared stable-raster-glyphs compositor hint so it
rasterizes once on its own layer and stays put — the same fix already
applied to buttons, badges, and timeline icons in #812. Applied at the
toolStatusDot snippet so both single and grouped tool cards are covered.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
Icon wobble has been fixed six times, always the same way, one surface
at a time (#165, #423, #424, #782, #812, 94f8bfe): give the icon its
own compositor layer so WKWebView repaints can't re-rasterize it at a
new fractional-pixel snap. The fix kept recurring because the
stable-raster hint is opt-in per element.

Every icon in the app renders via @lucide/svelte as
<svg class="lucide-icon lucide ...">, so extend the existing compositor
hint in app.css with a global svg.lucide selector. This retroactively
covers all past per-surface fixes and pins every future icon by
default. The opt-in .stable-raster / .stable-raster-glyphs classes stay
for the few non-lucide raster surfaces (badges, AgentIcon).

Checked for conflicts per the investigation note: the rotate(90deg)
caret rules target wrapper spans, not the SVG, so nothing overrides the
pin. svelte-check and tsc pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
… pin

Follow-up to the global svg.lucide compositor rule: the per-site
stable-raster-glyphs sprinkles on the button and badge variants, the
collapsed tool-call status dot, and the timeline meta-badge only exist
to pin lucide icons, which the global rule now covers — remove them.
The plain stable-raster hints stay where they pin the element's own
raster (badges, timeline icons, repo badges).

Hashtag badges keep stable-raster-glyphs: their icons are raw SVG
strings duplicating lucide paths (renderHashtagTokens emits plain HTML
where components can't render), so they carry no lucide class and the
global rule misses them.

Pin AgentIcon's five raw brand marks and the goose mask span with
stable-raster — the note's one remaining unpinned non-lucide surface;
the Bot fallback is lucide and already covered. This also keeps
AgentIcon stable inside buttons and badges now that those no longer
pin descendant SVGs.

svelte-check, tsc, and all 451 vitest tests pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
@matt2e
matt2e requested review from baxen and wesbillman as code owners July 30, 2026 03:33

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 76c0aa3133

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread apps/staged/src/app.css Outdated
Comment on lines 223 to 226
svg.lucide,
.stable-raster,
.stable-raster-glyphs svg {
transform: translateZ(0);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve transforms for animated lucide icons

This selector now gives every Lucide SVG a non-none base transform. That includes the loading toast icon in apps/staged/src/lib/components/ui/sonner/sonner.svelte, which uses Tailwind's animate-spin; Tailwind's spin keyframes only set to { transform: rotate(360deg) }, so the animation starts from translateZ(0) instead of rotate(0deg) and can collapse to a static identity transform. Please exclude animated icons from this pin or compose the raster pin into a spin-specific keyframe so loading indicators still visibly rotate.

Useful? React with 👍 / 👎.

Code review on the global svg.lucide translateZ(0) rule flagged that it
breaks Tailwind's animate-spin: the spin keyframes only define
to { rotate(360deg) }, so the implicit from frame inherits the pinned
translateZ(0). Mismatched transform lists interpolate via matrix
decomposition, where rotate(360deg) is the identity matrix — the
animation collapses to identity -> identity and the icon never visibly
rotates. The one affected surface is the sonner loading-toast icon
(sonner.svelte puts animate-spin directly on the lucide svg); the
shared Spinner component is raw SVG with SMIL animateTransform and all
caret/pane transforms target wrappers, so they are unaffected.

Exclude spinners with :not(.animate-spin): a continuously rotating icon
cannot exhibit subpixel wobble and is promoted to its own compositor
layer while animating anyway, so the exclusion loses nothing.

svelte-check and tsc pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
@matt2e
matt2e merged commit 427a665 into main Jul 30, 2026
4 checks passed
@matt2e
matt2e deleted the chat-ui-wobble branch July 30, 2026 05:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant